feat: process GutenbergKit media uploads with app Media settings - #25824
Open
dcalhoun wants to merge 18 commits into
Open
feat: process GutenbergKit media uploads with app Media settings#25824dcalhoun wants to merge 18 commits into
dcalhoun wants to merge 18 commits into
Conversation
dcalhoun
force-pushed
the
feat/process-gutenberg-kit-media-uploads
branch
from
July 22, 2026 20:12
5eb00ce to
f373395
Compare
dcalhoun
commented
Jul 22, 2026
dcalhoun
marked this pull request as ready for review
July 22, 2026 20:37
Contributor
|
| App Name | WordPress | |
| Configuration | Release-Alpha | |
| Build Number | 33809 | |
| Version | PR #25824 | |
| Bundle ID | org.wordpress.alpha | |
| Commit | 8b2acf3 | |
| Installation URL | 4utrj3snj5lpo |
Contributor
|
| App Name | Jetpack | |
| Configuration | Release-Alpha | |
| Build Number | 33809 | |
| Version | PR #25824 | |
| Bundle ID | com.jetpack.alpha | |
| Commit | 8b2acf3 | |
| Installation URL | 06a82ljiaecfo |
9 tasks
dcalhoun
force-pushed
the
feat/process-gutenberg-kit-media-uploads
branch
from
August 16, 2026 12:54
c908b31 to
e65c714
Compare
Collaborator
Generated by 🚫 Danger |
Gates the upcoming native media upload processing for the experimental block editor, with a Debug-menu override for quick disabling.
Points GutenbergKit at the XCFramework snapshot for wordpress-mobile/GutenbergKit#357, which adds the native media upload server and MediaUploadDelegate. Swap to a tagged release before merge.
Implements GutenbergKit's MediaUploadDelegate so device media picked in the experimental block editor is processed natively before upload, honoring the app's Media settings: Optimize Images, Max Image Upload Size, Image Quality, Max Video Upload Size, and Remove Location From Media. Previously these uploads went directly from the WebView to the REST API with no processing. GBKMediaUploadProcessor mirrors the exporter option mapping used by MediaImportService and reuses MediaURLExporter, so the editor now matches the behavior of the legacy editor and My Site > Media. GIFs and non-media files pass through untouched, and non-web-safe image formats (e.g. HEIC) are converted to JPEG. Uploads still use GutenbergKit's default uploader, which relays the raw WordPress response to the editor.
Verifies resizing, GPS stripping, HEIC-to-JPEG conversion, GIF and no-op passthrough, disallowed file extensions, and the video duration limit using the existing media fixtures.
Adds the gbkMediaUploadOptimization flag to the Experimental Features list and aligns display names with the "New Block Editor (NBE)" naming.
Removes the gbkMediaUploadOptimization gate ahead of reverting the flag. GutenbergKit degrades gracefully if the upload server cannot start, so a dedicated kill switch isn't needed.
…res" This reverts commit f373395.
This reverts commit ea1ad1f.
Moves the pin off the pr-build/357 snapshot now that wordpress-mobile/GutenbergKit#357 has merged. Trunk also carries the follow-up hardening in #561, which adds a defaulted handlesFile(ofType: named:) to MediaUploadDelegate, so GBKMediaUploadProcessor conforms unchanged. No tagged release includes #357 yet — v0.19.0 predates it. Swap to a tagged release before merge.
dcalhoun
force-pushed
the
feat/process-gutenberg-kit-media-uploads
branch
from
August 17, 2026 12:36
e65c714 to
435046a
Compare
Contributor
🤖 Build Failure AnalysisThis build has failures. Claude has analyzed them - check the build annotations for details. |
SVG conforms to `UTType.image`, so `MediaURLExporter.expectedExport` classifies it as `.image` and it reaches the exporter. ImageIO cannot decode or encode SVG: `CGImageSourceCreateWithURL` returns a source with zero images, and both `CGImageSourceCreateThumbnailAtIndex` and `CGImageDestinationCreateWithURL` return nil. The export therefore fails instead of producing a file. Return the original file for SVG, as we already do for GIF, and drop `.svg` from `webSafeImageTypes`. That set decides whether an image needs converting to JPEG, so it should only hold raster formats ImageIO can actually read and write; SVG's membership there implied it could reach the exporter safely. Only sites whose plan allows SVG can upload one, so this is not reachable on plans where the picker greys the file out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`MediaDirectory.temporary` is a computed property returning `.temporary(id: UUID())`, so each export was written to a fresh `tmp/<uuid>/Media/`. GutenbergKit's cleanup removes only the file at `uploadURL`, never the two enclosing directories, so every processed image or video left an empty directory pair behind for the lifetime of the process. Write every export to one directory identified by a fixed UUID. `MediaFileManager.makeLocalMediaURL` increments filenames, so uploads sharing a source name do not collide. The ID is stable across launches so a directory orphaned by a crash is reused rather than accumulating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment claimed the guard skips processing "when it would be a no-op", which is broader than what the condition tests. With image optimization off, `imageSizeForUpload` returns `Int.max` (no downscale) but `imageQualityForUpload` returns `.high`, so an image that falls through is still re-encoded at 0.9 quality. Describe what the guard actually checks and note that the quality mapping matches `MediaImportService`. No behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`makeExporter` is shared by the image and video paths, and it derived `exportImageType` from the source URL's type alone. A video's UTI is not in `webSafeImageTypes`, so every video export was configured to write JPEG. `MediaURLExporter.exportVideo` ignores `imageOptions`, so this had no effect, but it stated something untrue about the export. Pass the classification `processFile` already computed and set `exportImageType` only for an image. No behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`exportsShareOneTemporaryDirectory` cleaned up each export inside the loop body, so `defer` fired at the end of every iteration and deleted the file before the next export ran. `incrementalFilename` only increments while a file exists, so all three exports resolved to the same path and the directory set was trivially of size one. The test passed whether or not exports shared a directory. Collect the output URLs and clean them up after the loop so all three files coexist, and assert they are three distinct paths in one directory. Verified by reverting dd8c5bb locally: the test now fails with three directories instead of one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The root cross-platform package still pinned GutenbergKit to the trunk branch, left over from tracking trunk for native media uploads. Modules moved to the 0.20.0-alpha.0 tag, so re-resolve the root graph to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three related defects in GBKMediaUploadProcessor, all in the path between classifying an upload and handing the export back to GutenbergKit. Uploads without a file extension failed outright. `expectedExport` resolves the type from the path extension alone, and GutenbergKit names its temp file after the multipart `filename`, which the editor does not guarantee carries an extension — its native inserter derives one from a URL path segment. Such a file resolves to `public.data`, which conforms to no media type, so the export was rejected and the editor saw a 500 for a file that previously uploaded fine. Fall back to the reported MIME type, which the delegate already receives and ignored. Exports also had to stop going through `MediaURLExporter`, which re-derives the type from the path extension in `exportURL` and would reject the file again after it was classified. Use the concrete exporter per branch instead: `MediaImageExporter` reads the type from the file's contents via `CGImageSourceGetType`, so an extensionless image exports correctly. Replace the fixed export directory with one per export. Destination names come from `URL.incrementalFilename()`, a check-then-act `fileExists` loop with no locking, and uploads are processed concurrently, so a shared directory let two exports of the same source name resolve to the same path and clobber each other. Nothing sweeps that directory either — GutenbergKit removes only the file it is handed, and `MediaFileManager` cleans just the uploads directory — so a failure after the export wrote a file abandoned a full-size payload for the lifetime of the container. Clean up on the failure path. Derive the reported MIME type from `exportImageType` rather than re-reading the output path, so it comes from the value the export was configured with.
GutenbergKit asks the delegate, from the multipart headers alone, whether it will handle a file before streaming the upload to a temp file. The default is `true`, so every upload was materialized in full even when `processFile` immediately returned it unchanged — GIFs, and documents on a site with no extension restriction to enforce. Implement the gate for exactly those cases. It only declines where `processFile` returns `.original` for any Media settings, so it stays a fast path rather than a second place the policy lives; a parity test asserts that invariant across the settings matrix. Images and videos are always claimed: what happens to them depends on settings or on the file's contents, and declining is unrecoverable because the file is never seen again. Deciding from the reported MIME type needs it normalized first. `Content-Type` may carry parameters and arbitrary casing (RFC 9110 §8.3), and GutenbergKit's multipart parser substitutes `text/plain` for a part that sent no `Content-Type` at all (RFC 7578 §4.4) — it picks the file part by the presence of a `filename` parameter, not by content type. Left as-is, `image/jpeg; charset=binary` resolves to a dynamic type that conforms to nothing, and a real photo announced as `text/plain` looks like a document. Strip parameters, lower the casing, and treat the placeholder types as absent, falling back to the filename extension. `processFile` shares the helper, which also fixes the same misreading in its extensionless-upload fallback. Record why the image passthrough leaves EXIF orientation alone: the legacy exporter's unconditional normalization predates WordPress 5.3, whose `wp_create_image_subsizes` rotates server-side for every site, self-hosted included. Baking in a rotation the server performs anyway would cost a lossy re-encode of a photo the user asked not to optimize.
Member
Author
|
@jkmassel this work integrating GBK's media upload delegate is now ready for review. I updated the integration to match the latest GBK APIs and re-tested. Will you please review when you can? 🙇🏻♂️ |
Contributor
|
Version |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Description
Ref CMM-1249. Ref CMM-2155.
Related:
Integrates GutenbergKit's native media upload pipeline (wordpress-mobile/GutenbergKit#357) so device media picked in GutenbergKit is processed natively before upload, honoring the app's Media settings: Optimize Images, Max Image Upload Size, Image Quality, Max Video Upload Size, and Remove Location From Media. Previously these uploads went directly from the WebView to the REST API with no processing, bypassing the settings the legacy editor and My Site > Media already honor.
How it works
GBKMediaUploadProcessorimplements GutenbergKit'sMediaUploadDelegate. When the editor uploads a device file, GutenbergKit's localhost upload server hands the staged file toprocessFile, which configuresMediaImageExporterorMediaVideoExporterwith the same option mapping asMediaImportService:ItemProviderMediaExporter.MediaImportServicesets the preset unconditionally) and unlike Android, where video optimization is opt-in. The free-plan 5-minute duration limit is checked as a safety net, but the editor rejects video on free WP.com plans against the site's allowed mime types first, sovideoLimitExceededshould rarely be what a user hits.handlesFiledeclines files the processor provably won't touch — GIFs, and documents on a site with no extension restriction to enforce — so GutenbergKit forwards the original request body without first copying the file to disk. Images and videos are always claimed: what happens to them depends on Media settings or on the file's contents, and declining is unrecoverable because the delegate never sees the file again. A parity test asserts that anything declined would have returned.original, across the settings matrix.PostGBKEditorViewController(covers posts, pages, and custom post types). If GutenbergKit's upload server cannot start, it degrades gracefully to the standard WebView upload path.Handling uploads the editor describes imprecisely
The multipart headers are less trustworthy than they look, and two cases made valid uploads fail outright before being fixed here:
filename, and the editor doesn't guarantee that carries an extension — its native inserter derives one from a URL path segment (media.url.split('/').pop() || 'media'). Resolving the type from the path alone yieldspublic.data, which conforms to no media type, so the export was rejected and the editor saw a 500 for a file that previously uploaded fine. The type now falls back to the reported MIME type, and exports go through the concrete exporter rather thanMediaURLExporter, which re-derives the type from the extension inexportURLand would reject the file a second time.MediaImageExporterreads the type from the file's contents viaCGImageSourceGetType.Content-Typemay carry parameters and arbitrary casing (RFC 9110 §8.3), and GutenbergKit's multipart parser substitutestext/plainfor a part that sent noContent-Typeat all (RFC 7578 §4.4) — it picks the file part by the presence of afilenameparameter, not by content type. Left as-is,image/jpeg; charset=binaryresolves to a dynamic type conforming to nothing, and a real photo announced astext/plainlooks like a document. Parameters are stripped, casing lowered, and the placeholder types treated as absent, falling back to the filename extension.Temporary file handling
Each export gets its own temporary directory. Destination names come from
URL.incrementalFilename(), a check-then-actfileExistsloop with no locking, and GutenbergKit handles each connection in its own task, so a shared directory let two concurrent exports of the same source name resolve to the same path and clobber each other. Nothing sweeps that directory either — GutenbergKit removes only the file it is handed, andMediaFileManager's cleanup covers the uploads directory alone — so a failure after the export wrote a file abandoned a full-size payload for the lifetime of the container. The failure path now cleans up after itself.Notes
wp_create_image_subsizesrotates server-side for every site, self-hosted included. Baking in a rotation the server performs anyway would cost a lossy re-encode of a photo the user explicitly asked not to optimize. Android rotates here, but as a port of the same pre-5.3 workaround — and it already passes flagged files through untouched on WP.com. See Editor: vertical images appear horizontal in previews and on the web for self-hosted and Jetpack sites #12703 and core changeset 46202.imageQualityForUploadstill returns.high, so legacy rewrites a JPEG at quality 0.9. Passing the original bytes through avoids that generation loss. It only applies when the user has turned off both Optimize Images and Remove Location, which both default to on.Mediastore, so My Site > Media shows them after the next sync. This matches GutenbergKit's pre-existing behavior; syncing after upload is a possible follow-up.Blog.videoDurationLimitonly returns a limit for free WP.com sites, which are the same sites whoseallowed_file_typesexclude video. The limit is kept for defense in depth — it still applies if the editor's mime-type filtering changes or a site allows video on a free plan — but it isn't the path a user takes today.GBKMediaUploadProcessor.swift(new) and five lines ofPostGBKEditorViewController.swift, both underNewGutenberg/, plus the GutenbergKit dependency bump. Legacy Gutenberg and Aztec select different view controllers inEditorFactoryand never construct this processor.Testing instructions